<?php
/**
 * @author Todd Burry <todd@vanillaforums.com>
 * @copyright 2009-2019 Vanilla Forums Inc.
 * @license GPL-2.0-only
 */

namespace Vanilla\Dashboard\Models;

use Garden\EventManager;
use Garden\Schema\Schema;
use Garden\Web\ResourceRoute;
use Garden\Web\Route;
use ReflectionMethod;

/**
 * Maps a PHP controller method to an open API action.
 */
class ReflectionAction
{
    /**
     * @var ReflectionMethod
     */
    private $method;

    /**
     * @var EventManager
     */
    private $eventManager;

    /**
     * @var ResourceRoute $route
     */
    private $route;

    /**
     * @var string The name of the resource (the controller expressed as a path).
     */
    private $resource;

    /**
     * @var string
     */
    private $subpath;

    /**
     * @var string The http method of the resource.
     */
    private $httpMethod;

    /**
     * @var string
     */
    private $idParam;

    /**
     * @var string;
     */
    private $bodyParam;

    /**
     * @var string[]
     */
    private $params;

    /**
     * @var array
     */
    private $args;

    /**
     * @var object Instance to which the method belongs to.
     */
    private $methodInstance;

    /**
     * @var object The controller on which the action will be executed.
     */
    private $controllerInstance;

    /**
     * @var array
     */
    private $operation;

    /**
     * ReflectionAction constructor.
     *
     * @param ReflectionMethod $method The PHP method that the action is meant to represent.
     * @param object $methodInstance An object instance that the method belongs to.
     * @param object $controllerInstance The controller on which the action will be executed.
     * @param ResourceRoute $route The router used to inspect and quasi-reverse route the method.
     * @param EventManager $eventManager An event manager for capturing events.
     */
    public function __construct(
        ReflectionMethod $method,
        $methodInstance,
        $controllerInstance,
        ResourceRoute $route,
        EventManager $eventManager
    ) {
        $this->method = $method;
        $this->eventManager = $eventManager;
        $this->route = $route;
        $this->methodInstance = $methodInstance;
        $this->controllerInstance = $controllerInstance;

        $this->reflectAction();
    }

    /**
     * Reflect a controller action from a callback.
     *
     * @throws \InvalidArgumentException Throws an exception when the method name does not follow the convention used to
     * [map requests to methods](http://docs.vanillaforums.com/developer/framework/apiv2/resource-routing/#methods-names-actions).
     * @throws \InvalidArgumentException Throws an exception if the object in the callback is not named with the *ApiController suffix.
     */
    private function reflectAction()
    {
        $method = $this->method;
        $controllerPattern = $this->route->getControllerPattern();
        if (is_array($controllerPattern)) {
            $controllerPattern = reset($controllerPattern);
        }
        $resourceRegex = str_replace(["%s", "*\\"], ["([a-z][a-z0-9]*)", "(?:^|\\\\)"], $controllerPattern);

        // Regex the method name against event handler syntax or regular method syntax.
        if (
            preg_match(
                "`^(?:(?<class>$resourceRegex)_)?(?<method>get|post|patch|put|options|delete|index)(?:_(?<path>[a-z0-9]+?))?$`i",
                $method->getName(),
                $m
            )
        ) {
            $controller = $m["class"] ?: get_class($this->controllerInstance);
            $httpMethod = $m["method"];
            $subpath = isset($m["path"]) ? $m["path"] : "";
        } else {
            throw new \InvalidArgumentException("The method name does not match an action's pattern", 500);
        }

        if (strcasecmp($httpMethod, "index") === 0) {
            $httpMethod = "GET";
        }

        // Check against the controller pattern.
        if (preg_match("`$resourceRegex$`i", $controller, $m)) {
            $resource = $m[1];
        } else {
            throw new \InvalidArgumentException("The controller is not an API controller.", 500);
        }

        $this->httpMethod = strtoupper($httpMethod);
        $this->resource = $this->dashCase($resource);
        $this->subpath = ltrim("/" . $this->dashCase($subpath), "/");

        $this->args = [];
        $eventBound = $method->class !== $controller;

        foreach ($method->getParameters() as $param) {
            // The first parameter of eventBounds endpoint has to be the controller.
            if ($eventBound && $param->getPosition() === 0) {
                $this->args[$param->getName()] = $this->controllerInstance;
                continue;
            }

            // Default the call args.
            if ($param->isDefaultValueAvailable()) {
                $arg = $param->getDefaultValue();
            } elseif ($param->isArray()) {
                $arg = [];
            } elseif ($param->hasType()) {
                $type = $param->getType();
                switch (strtolower($type->__toString())) {
                    case "bool":
                        $arg = false;
                        $schemaType = "boolean";
                        break;
                    case "int":
                        $arg = 0;
                        $schemaType = "integer";
                        break;
                    case "float":
                        $arg = 0.0;
                        $schemaType = "float";
                        break;
                    case "string":
                        $arg = "";
                        break;
                    default:
                        $arg = null;
                }
            } else {
                $arg = null;
            }
            $this->args[$param->getName()] = $arg;

            $p = null;
            if ($this->route->isMapped($param, Route::MAP_BODY)) {
                $this->bodyParam = $param->getName();
                $p = ["name" => $param->getName(), "in" => "body", "required" => true];
            } elseif (!$param->getClass() && !$this->route->isMapped($param)) {
                $p = ["name" => $param->getName(), "in" => "path", "required" => true];
                if (isset($schemaType)) {
                    $p["type"] = $schemaType;
                }

                $constraint = (array) $this->route->getConstraint($param->getName()) + ["position" => "*"];

                $position = $param->getPosition();
                if ($eventBound) {
                    $position -= 1;
                }

                // Check if the "first" parameter is an idParam.
                if ($position === 0 && $constraint["position"] === $position) {
                    $this->idParam = $param->getName();
                }
            }

            if ($p !== null) {
                if ($param->isDefaultValueAvailable()) {
                    $p["default"] = $param->getDefaultValue();
                }

                $this->params[$p["name"]] = $p;
            }
        }
    }

    /**
     * Get the swagger operation array for this action.
     *
     * @return array Returns an operation array.
     * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#operationObject
     */
    public function getOperation()
    {
        if ($this->operation === null) {
            $this->operation = $this->makeOperation();
        }
        return $this->operation;
    }

    /**
     * Make the Swagger operation array for this action using a combination of reflection and event trapping.
     *
     * @return array Returns an operation array.
     * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#operationObject
     */
    private function makeOperation()
    {
        /* @var Schema $in, $allIn */
        /* @var Schema $out */
        /* @var Schema $allIn */
        $in = $out = $allIn = null;
        $summary = "";
        $other = [];

        // Set up an event handler that will capture the schemas.
        $fn = function ($controller, Schema $schema, $type) use (&$in, &$out, &$allIn) {
            $this->massageSchema($schema);

            switch ($type) {
                case "in":
                    if (empty($this->bodyParam)) {
                        if ($allIn instanceof Schema) {
                            $allIn = $allIn->merge($schema);
                        } else {
                            $allIn = $schema;
                        }
                    } elseif ($in !== null) {
                        $allIn = $in;
                    }
                    $in = $schema;
                    break;
                case "out":
                    $out = $schema;
                    throw new ShortCircuitException();
            }
        };

        try {
            $this->eventManager->bind("controller_schema", $fn, EventManager::PRIORITY_LOW);

            $r = $this->method->invoke($this->methodInstance, ...array_values($this->args));
        } catch (ShortCircuitException $ex) {
            // We should have everything we need now.
        } catch (\Throwable $ex) {
            $other["deprecated"] = true;
            // We shouldn't get here, but let's allow it.
            $summary =
                "Something happened before the output schema was found. The endpoint most likely didn't define its output properly.";
        } finally {
            $this->eventManager->unbind("controller_schema", $fn);
        }

        // Fill in information about the parameters from the input schema.
        if ($in instanceof Schema) {
            $summary = $summary ?: $in->getDescription();
            if (empty($summary) && $allIn instanceof Schema) {
                $summary = $allIn->getDescription();
            }
            $inArr = $this->jsonSerializeSchema($in);
            $allInArr = $allIn !== null ? $allIn->jsonSerialize() : [];
            unset($inArr["description"]);

            if (!empty($this->bodyParam)) {
                $this->params[$this->bodyParam]["schema"] = $inArr;
                /* @var array $property */
                foreach ($allInArr["properties"] as $name => $property) {
                    if (isset($this->params[$name])) {
                        $this->params[$name] = (array) $this->params[$name] + (array) $property;
                    }
                }
            } else {
                /* @var array $property */
                foreach ($allInArr["properties"] as $name => $property) {
                    if (isset($this->params[$name])) {
                        $this->params[$name] = (array) $this->params[$name] + (array) $property;
                    } else {
                        $this->params[$name] = ["name" => $name, "in" => "query"] + $property;
                    }
                    $param = &$this->params[$name];

                    if (isset($property["enum"]) && is_array($property["enum"])) {
                        $enumDescription =
                            "Must be one of: " . implode(", ", array_map("json_encode", $property["enum"])) . ".";

                        $param["description"] =
                            (empty($param["description"]) ? "" : rtrim($param["description"], ".") . ".\n") .
                            $enumDescription;
                    }

                    if (isset($param["description"])) {
                        $param["description"] = \Gdn_Format::to($param["description"], "markdown");
                    }

                    if (isset($allInArr["required"]) && in_array($name, $allInArr["required"])) {
                        $param["required"] = true;
                    } elseif (isset($param["required"])) {
                        unset($param["required"]);
                    }
                }
            }
        }

        // Make sure the parameters have a type now.
        foreach ($this->params as $name => &$param) {
            if ($param["in"] === "path") {
                $param += ["type" => $name === $this->idParam ? "integer" : "string"];
                $param["required"] = true;
            }

            if (array_key_exists("default", $param) && $param["default"] === null) {
                $param["x-default"] = null;
                unset($param["default"]);
            }
        }

        // Fill in the responses.
        $responses = [];
        if ($out instanceof Schema && !empty($out->getSchemaArray())) {
            $status = $this->httpMethod === "POST" && empty($this->idParam) ? "201" : "200";

            $responses[$status]["description"] = $out->getDescription() ?: "Success";
            $responses[$status]["schema"] = $this->jsonSerializeSchema($out);
        } else {
            $status = $this->httpMethod === "POST" && empty($this->idParam) ? "201" : "204";
            $responses[$status]["description"] = "Success";
        }

        $r =
            [
                "tags" => [$this->getNiceResourceName()],
                "summary" => $summary,
                "parameters" => array_values($this->params),
                "responses" => $responses,
            ] + $other;

        return array_filter($r);
    }

    /**
     * Convert a string from CapitalCase to dash-case.
     *
     * @param string $str The string to convert.
     * @return string Returns a dash-case string.
     */
    private function dashCase($str)
    {
        $str = preg_replace("`(?<![A-Z0-9])([A-Z0-9])`", '-$1', $str);
        $str = preg_replace("`(?<!-)([A-Z0-9])(?=[a-z])`", '-$1', $str);
        $str = trim($str, "-");

        return strtolower($str);
    }

    /**
     * Get the HTTP method for this action.
     *
     * @return string Returns the name of an HTTP method.
     */
    public function getHttpMethod()
    {
        return $this->httpMethod;
    }

    /**
     * Get the full path of the action.
     *
     * @return string Returns the path as a string.
     */
    public function getPath()
    {
        $r =
            "/" .
            $this->resource .
            ($this->idParam ? "/{" . $this->idParam . "}" : "") .
            (empty($this->subpath) ? "" : "/" . $this->subpath);

        foreach ($this->params as $key => $param) {
            if ($param["in"] === "path" && $key !== $this->idParam) {
                $r .= "/{" . $key . "}";
            }
        }

        return $r;
    }

    /**
     * Get the subpath of the action.
     *
     * The subpath occurs after the resource name and the ID parameter and narrows down an action even more. A general
     * resourceful endpoint would not have a subpath in which case this method will return an empty string.
     *
     * @return string Returns the subpath.
     */
    public function getSubpath()
    {
        return $this->subpath;
    }

    /**
     * Massage a schema for documentation display.
     *
     * @param Schema $schema The schema to massage.
     */
    private function massageSchema(Schema $schema)
    {
        $arr = $schema->getSchemaArray();
        $this->walkSchemas($arr, function (&$sch, $key, $parent) {
            if (is_array($sch["type"])) {
                // Check for a null type.
                if (count($sch["type"]) === 2 && in_array("null", $sch["type"])) {
                    $sch["x-nullable"] = true;
                    $arr = array_filter($sch["type"], function ($v) {
                        return $v !== "null";
                    });
                    $sch["type"] = array_pop($arr);
                }

                // Remove the boolean type from expand.
                if ($key === "expand" && count($sch["type"]) === 2 && in_array("boolean", $sch["type"])) {
                    $sch["type"] = "array";
                    if (isset($sch["default"])) {
                        if ($sch["default"] === false) {
                            unset($sch["default"]);
                        } elseif ($sch["default"] === "true") {
                            $sch["default"] = [\Vanilla\ApiUtils::EXPAND_ALL];
                        }
                    }
                }
            }

            if ($sch["style"] ?? "" === "form") {
                $sch["collectionFormat"] = "csv";
                unset($sch["style"]);
            }
        });

        $schema->setField([], $arr);
    }

    /**
     * Walk schemas recursively in a schema array.
     *
     * @param array &$array The schema array.
     * @param callable $callback The callback to execute on each schema array.
     * @param int $depth The current depth.
     */
    private function walkSchemas(array &$array, callable $callback, $depth = 0, array &$seen = null)
    {
        if ($seen === null) {
            $seen = [];
        }

        foreach ($array as $key => &$value) {
            if (is_array($value)) {
                if (isset($value["type"]) && $key !== "properties") {
                    $callback($value, $key, $array);
                }
                $this->walkSchemas($value, $callback, $depth + 1, $seen);
            } elseif ($value instanceof Schema) {
                $key = spl_object_hash($value);

                if (!isset($seen[$key])) {
                    $arr = $value->getSchemaArray();
                    $seen[$key] = $arr["id"] ?? true;
                    if (isset($value["type"]) && $key !== "properties") {
                        $callback($arr, $key, $array);
                    }
                    $this->walkSchemas($arr, $callback, $depth + 1, $seen);
                    $value->setField([], $arr);
                } else {
                    $foo = "bar";
                }
            }
        }
    }

    /**
     * Serialize a schema accounting for recursive schemas.
     *
     * @param Schema $schema The schema to serialize.
     * @return mixed Returns a JSON serializable value.
     */
    private function jsonSerializeSchema(Schema $schema)
    {
        // Fix recursive schemas.
        $arr = $schema->getSchemaArray();

        array_walk_recursive($arr, function (&$value, $key) use ($schema) {
            if ($value === $schema) {
                $value = ['$ref' => "#/definitions/" . $schema->getID()];
            } elseif ($value instanceof Schema) {
                $value = $this->jsonSerializeSchema($value);
            }
        });
        $schema2 = new Schema($arr);

        $jsonData = $schema2->jsonSerialize();
        return $jsonData;
    }

    /**
     * Generate  nice resource name from a dash-cased resource name.
     *
     * @return string Returns a resource name.
     */
    private function getNiceResourceName(): string
    {
        return implode(" ", array_map("ucfirst", explode("-", $this->resource)));
    }
}
